에러 바운더리가 잡지 못하는 오류

에러 바운더리가 잡지 못하는 오류

한눈에 보기

Error Boundary는 하위 React 트리가 렌더되는 동안 발생한 오류를 포착해 해당 영역을 fallback UI로 교체한다. 모든 JavaScript 오류를 처리하는 전역 try/catch가 아니다. event handler, setTimeout과 일반 Promise callback, 서버 렌더링, boundary 자신의 오류는 별도 경계에서 처리해야 한다. 사용자가 독립적으로 복구할 수 있는 UI 영역에 배치하고 오류 원인을 해결한 뒤 boundary와 데이터 상태를 함께 reset한다.

예시 코드 안내

본문의 코드는 특정 저장소 구현을 복사하지 않고 개념을 설명하기 위해 재구성한 예시다. 이름·경로·수치는 실제 운영 정보와 무관하다.

목차

Error Boundary가 필요한 이유

React 컴포넌트가 렌더 중 예외를 던지면 그 아래 UI를 정상적으로 계산할 수 없다.

function ProductPrice({ product }: { product: Product }) {
  if (product.price === null) {
    throw new Error("Product price is missing");
  }

  return <strong>{formatCurrency(product.price)}</strong>;
}

오류가 처리되지 않으면 React root의 UI가 제거되거나 framework의 전역 오류 화면으로 전환될 수 있다. 상품 가격 카드 하나의 잘못된 데이터 때문에 navigation과 장바구니까지 사라지는 것은 복구 범위가 지나치게 크다.

Error Boundary를 상품 상세 영역에 두면 상위 shell은 유지하고 깨진 subtree만 대체할 수 있다.

function ProductPage() {
  return (
    <ApplicationLayout>
      <ProductHeader />
      <ErrorBoundary fallback={<ProductPanelError />}>
        <ProductDetail />
      </ErrorBoundary>
      <RecommendationPanel />
    </ApplicationLayout>
  );
}
flowchart TB
    A[ApplicationLayout] --> B[ProductHeader]
    A --> C[ErrorBoundary]
    C --> D[ProductDetail 오류]
    C --> E[ProductPanelError로 대체]
    A --> F[RecommendationPanel 유지]
복구 경계

Error Boundary는 오류를 없애는 도구가 아니라 오류가 영향을 미치는 UI 범위를 제한하고 사용자가 취할 다음 행동을 제공하는 도구다.

기본 Error Boundary 구현

현재 React에서는 직접 Error Boundary를 구현할 때 class component의 getDerivedStateFromErrorcomponentDidCatch를 사용한다. 일반 function component만으로 같은 boundary API를 직접 구현하는 Hook은 아직 없다. 검증된 react-error-boundary 같은 library를 사용할 수도 있다.

import {
  Component,
  type ErrorInfo,
  type ReactNode,
} from "react";

type ErrorBoundaryProps = {
  children: ReactNode;
  fallback: ReactNode;
  onError?: (error: unknown, info: ErrorInfo) => void;
};

type ErrorBoundaryState = {
  hasError: boolean;
};

class ErrorBoundary extends Component<
  ErrorBoundaryProps,
  ErrorBoundaryState
> {
  state: ErrorBoundaryState = {
    hasError: false,
  };

  static getDerivedStateFromError(): ErrorBoundaryState {
    return { hasError: true };
  }

  componentDidCatch(error: unknown, info: ErrorInfo): void {
    this.props.onError?.(error, info);
  }

  render(): ReactNode {
    if (this.state.hasError) {
      return this.props.fallback;
    }

    return this.props.children;
  }
}

두 메서드의 역할은 다르다.

<ErrorBoundary
  fallback={<p role="alert">상품 정보를 표시할 수 없습니다.</p>}
  onError={(error, info) =>
    reportUiError({
      error,
      componentStack: info.componentStack,
      area: "product-detail",
    })
  }
>
  <ProductDetail />
</ErrorBoundary>

fallback component 자체도 단순하고 의존성이 적어야 한다. 동일한 formatter나 Context를 사용하다 다시 오류가 나면 이 boundary가 처리할 수 없다.

어떤 오류를 잡고 어떤 오류를 놓치는가

공식 React 문서 기준으로 일반적인 범위는 다음과 같다.

오류 위치 현재 boundary가 처리하는가 처리 위치
하위 component render Error Boundary
하위 class lifecycle Error Boundary
하위 constructor Error Boundary
event handler 아니오 handler의 try/catch·mutation state
setTimeout callback 아니오 callback 내부 처리
일반 Promise의 async continuation 아니오 catch·요청 도구
서버 렌더링 아니오 framework/server error boundary
boundary 자신의 render/fallback 아니오 더 위의 Error Boundary

다음 try/catch도 자식 렌더 오류를 잡지 못한다.

function ProductSection() {
  try {
    return <ProductDetail />;
  } catch {
    return <ProductPanelError />;
  }
}

JSX를 만드는 시점에 ProductDetail 함수가 즉시 이 자리에서 호출되는 것이 아니다. React가 나중에 자식 component를 렌더하므로 부모 함수의 try/catch 호출 스택 밖에서 오류가 발생한다.

Error Boundary를 사용한다.

function ProductSection() {
  return (
    <ErrorBoundary fallback={<ProductPanelError />}>
      <ProductDetail />
    </ErrorBoundary>
  );
}

event handler 오류는 직접 처리한다

버튼 click handler는 이미 화면이 성공적으로 commit된 뒤 사용자 상호작용으로 실행된다. handler가 예외를 던져도 다음 UI를 계산하던 중이 아니므로 Error Boundary가 fallback으로 바꾸지 않는다.

function ExportButton() {
  function handleExport() {
    throw new Error("export failed");
  }

  return <button onClick={handleExport}>내보내기</button>;
}

예상 가능한 실패는 handler에서 처리해 state나 toast로 표현한다.

function ExportButton() {
  const [error, setError] = useState<string | null>(null);

  async function handleExport() {
    setError(null);

    try {
      const file = await createExport();
      download(file);
    } catch (cause) {
      setError(toUserMessage(cause));
      reportActionError(cause, { action: "export" });
    }
  }

  return (
    <>
      <button type="button" onClick={handleExport}>
        내보내기
      </button>
      {error && <p role="alert">{error}</p>}
    </>
  );
}

버튼 handler 전체를 하나의 generic wrapper로 감싸더라도 사용자에게 어떤 복구를 제공할지는 action 문맥에 따라 달라진다. 인증 만료는 로그인 화면, 네트워크 오류는 재시도, validation 오류는 field 안내가 필요하다.

예상하지 못한 event 오류를 전역 오류 수집기가 기록할 수는 있지만, 그것이 Error Boundary처럼 UI를 안전한 상태로 되돌려 주지는 않는다.

비동기 요청 실패는 상태로 표현한다

Effect 안에서 시작한 Promise가 나중에 reject되는 것은 렌더 호출 스택 밖의 사건이다.

useEffect(() => {
  fetchProduct(productId).then(setProduct);
}, [productId]);

reject를 처리하지 않으면 unhandled rejection이 된다. 요청 state를 명시적으로 다룬다.

useEffect(() => {
  const controller = new AbortController();

  async function load() {
    setState({ status: "pending" });

    try {
      const product = await fetchProduct(
        productId,
        controller.signal,
      );
      setState({ status: "success", product });
    } catch (error) {
      if (!controller.signal.aborted) {
        setState({
          status: "error",
          error: toError(error),
        });
      }
    }
  }

  void load();
  return () => controller.abort();
}, [productId]);

렌더에서 해당 state에 맞는 UI를 선택한다.

if (state.status === "pending") {
  return <ProductSkeleton />;
}

if (state.status === "error") {
  return (
    <ProductLoadError
      error={state.error}
      onRetry={reload}
    />
  );
}

TanStack Query 같은 도구는 pending, error, refetch와 cache를 관리한다.

const query = useQuery({
  queryKey: ["product", productId],
  queryFn: ({ signal }) => fetchProduct(productId, signal),
});

요청 실패를 inline UI로 보여 줄지 render에서 throw해 Error Boundary에 맡길지는 복구 단위에 따라 결정한다. field-level 요청 실패까지 페이지 전체 fallback으로 보내면 범위가 너무 넓다.

Promise를 렌더에서 읽는 경우는 다르게 동작한다

React의 use API나 Suspense 통합 query가 rejected Promise를 렌더 과정에 전달하면 가장 가까운 Error Boundary로 오류가 전파될 수 있다.

function AlbumList({
  albumsPromise,
}: {
  albumsPromise: Promise<Album[]>;
}) {
  const albums = use(albumsPromise);

  return (
    <ul>
      {albums.map((album) => (
        <li key={album.id}>{album.title}</li>
      ))}
    </ul>
  );
}
<ErrorBoundary fallback={<AlbumError />}>
  <Suspense fallback={<AlbumSkeleton />}>
    <AlbumList albumsPromise={albumsPromise} />
  </Suspense>
</ErrorBoundary>

이는 “모든 async 오류를 Error Boundary가 잡는다”는 뜻이 아니다. React가 렌더 중 읽는 resource로 통합되어 오류가 render path로 전달된 경우다. event handler에서 시작한 임의 Promise rejection은 여전히 직접 처리해야 한다.

사용하는 React와 framework 버전의 data fetching 계약을 확인한다.

서버 렌더링 오류는 서버 경계에서 처리한다

Error Boundary class는 클라이언트 렌더 트리 복구에 사용되며 서버 렌더링 중 발생한 오류를 같은 방식으로 잡지 않는다.

export async function renderRequest(request: Request) {
  return renderToString(
    <ErrorBoundary fallback={<ServerError />}>
      <App request={request} />
    </ErrorBoundary>,
  );
}

App의 서버 렌더 오류를 이 boundary가 브라우저처럼 fallback으로 바꿔 줄 것이라고 가정하지 않는다. 사용하는 framework의 route error file, server handler, streaming error 처리 방식을 사용한다.

서버에서는 다음 책임이 필요하다.

async function handleRequest(request: Request): Promise<Response> {
  try {
    return await renderApplication(request);
  } catch (error) {
    const requestId = crypto.randomUUID();
    reportServerRenderError(error, { requestId });

    return new Response(renderMinimalErrorPage(requestId), {
      status: 500,
      headers: { "Content-Type": "text/html; charset=utf-8" },
    });
  }
}

Next.js 같은 framework에서는 직접 이 코드를 만들기보다 제공되는 error boundary와 route convention을 따른다.

boundary 자신의 오류는 상위 boundary가 처리한다

ErrorBoundary의 fallback이 렌더 중 다시 오류를 던질 수 있다.

function ProductPanelError() {
  const message = useTranslations().errors.productLoad;
  return <p>{message.toUpperCase()}</p>;
}

Translation Provider 자체가 깨졌거나 message가 undefined라면 fallback도 실패한다. 현재 boundary는 자신의 렌더 오류를 잡을 수 없고 상위 boundary로 전파된다.

<ErrorBoundary fallback={<MinimalAppError />}>
  <ApplicationShell>
    <ErrorBoundary fallback={<ProductPanelError />}>
      <ProductDetail />
    </ErrorBoundary>
  </ApplicationShell>
</ErrorBoundary>

최상위 fallback은 가능한 한 의존성이 적어야 한다.

function MinimalAppError() {
  return (
    <main>
      <h1>화면을 표시할 수 없습니다.</h1>
      <button type="button" onClick={() => window.location.reload()}>
        새로고침
      </button>
    </main>
  );
}

boundary의 componentDidCatch 로거도 예외를 던지지 않게 방어한다. 오류 보고 실패 때문에 fallback까지 깨져서는 안 된다.

앱 어디에 boundary를 배치할까

모든 component를 개별 boundary로 감싸면 fallback이 잘게 쪼개지고 코드가 복잡해진다. 앱 전체에 하나만 두면 작은 오류가 전체 화면을 덮는다.

사용자가 독립적으로 이해하고 복구할 수 있는 영역을 기준으로 둔다.

위치 적합한 fallback
app root 최소 오류 화면과 새로고침
route/page 이전 route 이동, 페이지 다시 시도
dashboard widget 해당 widget만 재시도
메시지 작성기 draft 보존과 재초기화
개별 avatar 보통 기본 이미지 처리로 충분
function Dashboard() {
  return (
    <DashboardLayout>
      <ErrorBoundary fallback={<SalesWidgetError />}>
        <SalesWidget />
      </ErrorBoundary>

      <ErrorBoundary fallback={<InventoryWidgetError />}>
        <InventoryWidget />
      </ErrorBoundary>
    </DashboardLayout>
  );
}

매출 widget이 깨져도 재고 widget은 사용할 수 있다.

다만 두 widget이 하나의 transaction form처럼 함께 동작해야 한다면 따로 fallback이 나타나는 것이 더 혼란스러울 수 있다. UI의 업무 단위를 기준으로 경계를 잡는다.

경계 질문

이 subtree가 사라져도 나머지 화면을 안전하게 사용할 수 있는가? 사용자가 이 영역만 다시 시도할 수 있는가?

retry는 fallback만 숨기는 일이 아니다

class Error Boundary가 hasError: true가 되면 자식은 unmount된다. retry하려면 오류 원인을 해결하고 boundary state를 reset해야 한다.

검증된 library를 사용하는 개념적인 예제다.

function ProductRoute({ productId }: Props) {
  return (
    <QueryErrorResetBoundary>
      {({ reset }) => (
        <ErrorBoundary
          resetKeys={[productId]}
          onReset={reset}
          fallbackRender={({ resetErrorBoundary }) => (
            <ProductError
              onRetry={resetErrorBoundary}
            />
          )}
        >
          <ProductDetail productId={productId} />
        </ErrorBoundary>
      )}
    </QueryErrorResetBoundary>
  );
}

query error 상태와 UI boundary를 함께 reset한다. boundary만 reset했는데 query cache가 같은 error를 즉시 다시 throw하면 fallback으로 돌아온다.

직접 만든 boundary에는 reset key 변경을 감지하는 정책을 추가할 수 있다. 그러나 오류 직후 반복 렌더 loop가 생기지 않게 해야 한다.

<ResettableErrorBoundary
  resetKey={productId}
  fallback={ProductError}
>
  <ProductDetail productId={productId} />
</ResettableErrorBoundary>

retry button은 중복 클릭을 막고 진행 상태를 표시한다. 재시도해도 계속 실패하면 같은 오류를 무한 자동 재시도하지 않는다.

오류 로깅과 개인정보 보호

componentDidCatch는 error와 component stack을 보고할 수 있다.

function reportBoundaryError(
  error: unknown,
  info: ErrorInfo,
  context: ErrorContext,
) {
  errorReporter.capture({
    name: getErrorName(error),
    message: sanitizeErrorMessage(error),
    stack: getErrorStack(error),
    componentStack: info.componentStack,
    routePattern: context.routePattern,
    boundary: context.boundary,
    requestId: context.requestId,
  });
}

로그에 넣지 않을 것:

source map은 비공개 오류 수집 서비스에 업로드해 minified stack을 복원하되 공개 배포 여부를 검토한다.

동일 오류가 렌더 loop로 반복될 때 보고 폭주를 막기 위해 fingerprint와 rate limit을 둔다.

const fingerprint = hash({
  errorName,
  topStackFrame,
  boundaryName,
  buildVersion,
});

React 버전이 지원하면 createRootonCaughtError, onUncaughtError, onRecoverableError를 전역 관측에 사용할 수 있다. framework가 root를 소유한다면 자체 integration을 따른다. Error Boundary의 사용자 복구 UI와 root error reporting은 서로 다른 책임이다.

Suspense와 Error Boundary의 역할 차이

Suspense fallback과 Error Boundary fallback은 비슷한 모양으로 보여도 의미가 다르다.

경계 상태 사용자 메시지
Suspense 아직 준비되지 않음 로딩 중
Error Boundary 렌더할 수 없음 실패, 재시도·이동
<ErrorBoundary fallback={<PanelError />}>
  <Suspense fallback={<PanelSkeleton />}>
    <AsyncPanel />
  </Suspense>
</ErrorBoundary>

pending Promise는 Error가 아니다. 로딩을 오류로 표시하지 않고, 오류를 무한 skeleton으로 숨기지 않는다.

fallback이 화면 layout을 크게 바꾸면 content shift가 생길 수 있다. widget boundary는 원래 영역과 비슷한 최소 높이를 유지할 수 있다.

테스트해야 할 실패 경로

렌더 오류를 실제로 던지는 test component를 만든다.

function BrokenWidget(): never {
  throw new Error("test render failure");
}
it("하위 렌더 오류를 widget fallback으로 격리한다", () => {
  render(
    <DashboardLayout>
      <ErrorBoundary fallback={<p>위젯 오류</p>}>
        <BrokenWidget />
      </ErrorBoundary>
      <nav>주 메뉴</nav>
    </DashboardLayout>,
  );

  expect(screen.getByText("위젯 오류")).toBeVisible();
  expect(screen.getByText("주 메뉴")).toBeVisible();
});

retry는 원인 reset과 함께 검증한다.

it("다시 시도하면 query 오류를 reset하고 내용을 표시한다", async () => {
  server.product
    .mockRejectedValueOnce(new Error("temporary"))
    .mockResolvedValueOnce(product);

  const user = userEvent.setup();
  renderProductRoute();

  await user.click(
    await screen.findByRole("button", { name: "다시 시도" }),
  );

  expect(await screen.findByText(product.name)).toBeVisible();
});

별도로 확인할 항목:

정리

Error Boundary는 React tree가 렌더될 수 없는 오류를 UI 복구 경계로 제한한다. 모든 JavaScript 예외를 자동 처리하지 않는다.

Error Boundary의 목적은 오류를 모두 잡는 것이 아니라 하나의 깨진 subtree가 전체 애플리케이션을 사용할 수 없게 만드는 일을 막는 것이다.

관련 노트와 참고 자료